Conversation
* feat(COMPT-50): implement Zod-based env schema validation engine - Add defineConfig(schema) returning ConfigDefinition<T> - Add ConfigDefinition.parse() — validates process.env synchronously, returns frozen fully-typed config, throws ConfigValidationError on failure - Add ConfigValidationError extending Error with fields: ZodIssue[] - Add zod as peerDependency (^3 || ^4) - Install zod as devDependency for local development - Update src/index.ts to export defineConfig, ConfigDefinition, ConfigValidationError - Add node to tsconfig types array for process.env access * chore: bump version to 0.1.0 * style: apply prettier formatting across all files * fix(lint): use import type for z in define-config.ts
* feat(COMPT-50): implement Zod-based env schema validation engine - Add defineConfig(schema) returning ConfigDefinition<T> - Add ConfigDefinition.parse() — validates process.env synchronously, returns frozen fully-typed config, throws ConfigValidationError on failure - Add ConfigValidationError extending Error with fields: ZodIssue[] - Add zod as peerDependency (^3 || ^4) - Install zod as devDependency for local development - Update src/index.ts to export defineConfig, ConfigDefinition, ConfigValidationError - Add node to tsconfig types array for process.env access * chore: bump version to 0.1.0 * style: apply prettier formatting across all files * fix(lint): use import type for z in define-config.ts * feat(COMPT-51): implement ConfigModule and ConfigService - Add ConfigModule with register() sync, registerAsync() async factory, and forRoot() global registration - Add ConfigService<TDef> with typed get<K>(key) — return type inferred directly from Zod schema output, no casting needed - Add CONFIG_VALUES_TOKEN internal DI token in constants.ts - Validation runs before any provider resolves in all three registration modes - App fails to boot when validation fails (ConfigValidationError thrown) - Update src/index.ts to export ConfigModule, ConfigService, ConfigModuleAsyncOptions
* implemented namespaced config * fixed prettier error * fix(lint): fix import order in config.module.ts
* implemented test suite * style: apply prettier formatting across all files
* readme changeset * fixed prettier and bumped version
* removed template files and added new tests * fix: use import type for InferConfig in spec * fixed tag issue
There was a problem hiding this comment.
Pull request overview
This PR repurposes the repo from a NestJS module template into @ciscode/config-kit, introducing a Zod-based configuration system (root + namespaced slices) for NestJS that validates process.env at startup and provides typed access via ConfigService.
Changes:
- Replaced template “Example*” module/service/controller/repository boilerplate with ConfigKit’s
defineConfig,ConfigModule,ConfigService, anddefineNamespaceAPIs. - Added new error types (
ConfigValidationError,DuplicateNamespaceError) and comprehensive Jest coverage for the new runtime behavior. - Updated package metadata/docs (README/CHANGELOG), TS config, and minor CI/workflow formatting.
Reviewed changes
Copilot reviewed 30 out of 32 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| tsconfig.json | Adds Node types to support process.env typing in the new config implementation/tests. |
| src/index.ts | Updates public exports to expose ConfigKit APIs and stop exporting template artifacts. |
| src/errors/config-validation.error.ts | Adds a custom startup validation error carrying Zod issues. |
| src/errors/config-validation.error.spec.ts | Adds unit tests for ConfigValidationError. |
| src/define-config.ts | Introduces defineConfig() / ConfigDefinition.parse() and InferConfig helper. |
| src/define-config.spec.ts | Tests parse behavior (defaults, coercion, error paths, immutability). |
| src/constants.ts | Adds internal DI tokens and namespace token generator. |
| src/config.service.ts | Adds typed ConfigService with .get() and .getAll(). |
| src/config.module.ts | Adds dynamic module APIs (register, registerAsync, forRoot) and namespace registry wiring. |
| src/config.module.spec.ts | Integration tests for module registration modes and service behavior. |
| src/decorators/inject-config.decorator.ts | Adds @InjectConfig() decorator for namespaced slice injection. |
| src/define-namespace.ts | Adds per-feature namespace config (defineNamespace, NamespacedConfig, duplicate detection error). |
| src/define-namespace.spec.ts | Integration tests for namespacing and DI integration. |
| README.md | Rewrites documentation for ConfigKit usage and API reference. |
| CHANGELOG.md | Introduces a package changelog for ConfigKit. |
| package.json | Renames package to @ciscode/config-kit and updates dependencies/peer deps. |
| package-lock.json | Updates lockfile to reflect dependency changes (incl. zod). |
| jest.config.ts | Raises global coverage thresholds to 85%. |
| .github/workflows/release-check.yml | Formatting-only changes (quote style). |
| .github/workflows/publish.yml | Formatting-only changes (quote style). |
| .github/dependabot.yml | Formatting-only changes (quote style). |
| .changeset/thick-maps-raise.md | Removes an old template changeset. |
| .changeset/config.json | Updates changesets repo metadata for the new repository/package. |
| src/services/example.service.ts | Removes template service (no longer relevant). |
| src/example-kit.module.ts | Removes template module (replaced by ConfigModule). |
| src/controllers/example.controller.ts | Removes template controller. |
| src/guards/example.guard.ts | Removes template guard. |
| src/repositories/example.repository.ts | Removes template repository. |
| src/entities/example.entity.ts | Removes template entity. |
| src/dto/create-example.dto.ts | Removes template DTO. |
| src/dto/update-example.dto.ts | Removes template DTO. |
| src/decorators/example.decorator.ts | Removes template decorator. |
Comments suppressed due to low confidence (1)
package.json:56
@nestjs/platform-expressis listed as a peer dependency even though the package code doesn't import it; this unnecessarily forces consumers onto the Express platform (and complicates Fastify apps). Also,class-transformer/class-validatorare runtime dependencies but appear unused insrc/now—keeping them increases install surface area. Consider removing unused deps/peer deps (or making platform peer optional) to match actual runtime requirements.
"peerDependencies": {
"@nestjs/common": "^10 || ^11",
"@nestjs/core": "^10 || ^11",
"@nestjs/platform-express": "^10 || ^11",
"reflect-metadata": "^0.2.2",
"rxjs": "^7",
"zod": "^3 || ^4"
},
"dependencies": {
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1"
},
| @Global() // Applied only when used via forRoot(); register() and registerAsync() are non-global | ||
| @Module({}) | ||
| export class ConfigModule { |
There was a problem hiding this comment.
@Global() on the module class makes ConfigModule global for all import styles (register/registerAsync/forRoot). This contradicts the inline comment and the returned DynamicModule objects setting global: false, and can unintentionally leak ConfigService across modules. Consider removing @Global() and relying solely on global: true in forRoot(), or otherwise update the implementation/docs so register/registerAsync are truly non-global.
| private static createAsyncConfigProvider<T extends AnyZodObject>( | ||
| options: ConfigModuleAsyncOptions<T>, | ||
| ): Provider { | ||
| if (options.useFactory) { | ||
| // ── useFactory ───────────────────────────────────────────────────────── | ||
| return { | ||
| provide: CONFIG_VALUES_TOKEN, | ||
| useFactory: async (...args: unknown[]) => { | ||
| // Call the consumer's factory to get the ConfigDefinition | ||
| const definition = await options.useFactory!(...args); | ||
| // Parse and validate synchronously — throws on bad env before app boots | ||
| return definition.parse(process.env); | ||
| }, | ||
| inject: (options.inject ?? []) as never[], | ||
| }; | ||
| } | ||
|
|
||
| // ── useClass / useExisting ──────────────────────────────────────────────── | ||
| // Both patterns delegate to ConfigModuleOptionsFactory.createConfigDefinition() | ||
| // useClass → NestJS creates a fresh instance of the class | ||
| // useExisting → NestJS reuses the already-registered instance | ||
| const factoryToken = (options.useClass ?? options.useExisting)!; | ||
|
|
There was a problem hiding this comment.
createAsyncConfigProvider() assumes that if useFactory is not provided, then useClass or useExisting is set (via a non-null assertion). If a consumer passes invalid options (none or multiple of these fields), this will fail with a confusing runtime error. Add explicit validation in registerAsync() (or here) to enforce exactly one of useFactory/useClass/useExisting and throw a clear error message when the contract is violated.
| * @param env - The environment record to validate. | ||
| * Defaults to `process.env` so consumers never need to pass it. | ||
| * @returns A deep-frozen, fully-typed config object with no `string | undefined` values. | ||
| * Zod coerces types and fills in `.default()` values automatically. | ||
| * @throws {ConfigValidationError} When one or more fields fail validation. | ||
| * The error lists every failing ZodIssue so developers can fix all | ||
| * problems in a single restart cycle. | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * // Called internally by ConfigModule — you rarely call this yourself | ||
| * const config = appConfig.parse(process.env); | ||
| * config.PORT; // string (never undefined) | ||
| * config.DATABASE_URL; // string | ||
| * ``` | ||
| */ | ||
| parse(env: Record<string, string | undefined> = process.env): Readonly<z.output<T>> { | ||
| // Run a safe (non-throwing) parse so we can collect ALL issues at once | ||
| const result = this.schema.safeParse(env); | ||
|
|
||
| // If validation failed, throw with the full ZodIssue list | ||
| if (!result.success) { | ||
| throw new ConfigValidationError(result.error.issues); | ||
| } | ||
|
|
||
| // Freeze the resulting object so consumers cannot mutate config at runtime | ||
| return Object.freeze(result.data) as Readonly<z.output<T>>; | ||
| } |
There was a problem hiding this comment.
The JSDoc says parse() returns a "deep-frozen" config object, but the implementation only uses Object.freeze(result.data), which is shallow and allows mutation of nested objects/arrays. Either implement a deep-freeze (recursive) before returning, or update the docs to reflect shallow immutability and any guarantees/limitations.
| export function defineNamespace<T extends AnyZodObject>( | ||
| namespace: string, | ||
| schema: T, | ||
| ): NamespacedConfig<T> { | ||
| // No side effects at definition time — validation is deferred to asProvider() | ||
| return new NamespacedConfig(namespace, schema); |
There was a problem hiding this comment.
defineNamespace() accepts any string for namespace (including empty/whitespace), which would generate ambiguous DI tokens (e.g. CONFIG_KIT_NS:) and make duplicate detection/error messages confusing. Consider validating namespace (e.g. non-empty after trim, possibly restrict characters) and throwing a clear error early.
| # @ciscode/config-kit | ||
|
|
||
| ## 0.3.0 | ||
|
|
||
| ### Minor Changes | ||
|
|
||
| - Added ConfigService.getAll(), InferConfig<T> type helper, useClass/useExisting support in registerAsync, and removed template boilerplate from the published package | ||
|
|
There was a problem hiding this comment.
The package version is 0.3.1 (package.json) but the changelog's latest entry is 0.3.0. Add a 0.3.1 section or adjust versioning so CHANGELOG and published version stay in sync.
| | Method | Returns | | ||
| | ----------------- | --------------------------------------------------------------------- | | ||
| | `config.get(key)` | Zod output type for `key` — never `undefined` unless schema allows it | |
There was a problem hiding this comment.
README API reference for ConfigService<TDef> only documents get(key), but the PR introduces getAll() and exports it as part of the public service API. Please document getAll() here (and, if intended, clarify whether it returns the same frozen reference or a copy).
| | Method | Returns | | |
| | ----------------- | --------------------------------------------------------------------- | | |
| | `config.get(key)` | Zod output type for `key` — never `undefined` unless schema allows it | | |
| | Method | Returns | Notes | | |
| | ------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------- | | |
| | `config.get(key)` | Zod output type for `key` — never `undefined` unless schema allows it | Reads a single validated config value by key | | |
| | `config.getAll()` | Fully validated config object for `TDef` | Returns the full config object; this README does not guarantee whether it is the same frozen reference or a copy | |
| | Method | Description | | ||
| | ------------------------------------- | ---------------------------------------------------------- | | ||
| | `ConfigModule.forRoot(definition)` | Global registration — `ConfigService` available everywhere | | ||
| | `ConfigModule.register(definition)` | Non-global registration — scoped to the importing module | | ||
| | `ConfigModule.registerAsync(options)` | Async registration with `useFactory` / `inject` | |
There was a problem hiding this comment.
In the API reference table for ConfigModule, registerAsync(options) is described only in terms of useFactory/inject, but the module now also supports useClass and useExisting patterns. Consider updating the README to reflect all supported option shapes so consumers don't miss these capabilities.
| | Method | Description | | |
| | ------------------------------------- | ---------------------------------------------------------- | | |
| | `ConfigModule.forRoot(definition)` | Global registration — `ConfigService` available everywhere | | |
| | `ConfigModule.register(definition)` | Non-global registration — scoped to the importing module | | |
| | `ConfigModule.registerAsync(options)` | Async registration with `useFactory` / `inject` | | |
| | Method | Description | | |
| | ------------------------------------- | ----------------------------------------------------------------------------- | | |
| | `ConfigModule.forRoot(definition)` | Global registration — `ConfigService` available everywhere | | |
| | `ConfigModule.register(definition)` | Non-global registration — scoped to the importing module | | |
| | `ConfigModule.registerAsync(options)` | Async registration with `useFactory` / `inject`, `useClass`, or `useExisting` | |
Summary
Why
Checklist
npm run lintpassesnpm run typecheckpassesnpm testpassesnpm run buildpassesnpx changeset) if this affects consumersNotes